<!DOCTYPE html>
<html lang="zh-cn">
<head>
  
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
  <title>
谈谈 iOS8 中的 Network Extension |
穷折腾</title>
  <link rel="stylesheet" href="/static/css/style.css" />
  <link rel="stylesheet" href="/static/css/pygments.css" />
  <link rel="alternate" type="application/rss+xml" title="RSS" href="http://blog.zorro.im/rss.xml" />
  <link rel="icon" type="image/png" href="/static/img/favicon.png">
</head>
<body>
    <div class="page-wrap">
        <section class="main-header regular">
            <div class="vertical-container">
                <div class="wrapper clearfix">
                    <header>
                        <a href="/">
                            <h1 id="main-title">穷折腾</h1>
                            <h2 id="main-subtitle">zqqf16 的个人博客</h2>
                        </a>
                    </header>
                    <nav id="main-nav" role="navigation">
                        <a href="/" class="selected">主页</a>
                        <a href="/posts/about.html" class="">关于</a>
                        <a href="https://github.com/zqqf16">Github</a>
                    </nav>
                </div>
            </div>
        </section>
        <section class="main-content">
            
<article class="main-content">
    <div class="wrapper">
        <header class="section-header">
            <h1 class="section-title">谈谈 iOS8 中的 Network Extension</h1>
            <span class="section-subtitle"><time datetime="2014-10-14" pubdate="">2014-10-14</time></span>
        </header>
        <section class="post-content">
        	<blockquote>
<p>由于工作原因，对 iOS 的 VPN 方面比较关心，于是基本上就在第一时间发现并研究了 Network Extension（以下简称 NE）。</p>
</blockquote>
<h2 id="_1">介绍</h2>
<p>NE 向应用开放了 VPN（Personal VPN）的权限，开发者可以创建、修改、删除 VPN 配置，启动、停止 VPN，以及获取 VPN 状态等。目前只支持 IPSec 和 IKEv2。</p>
<p>可能是 NE 的受众太小，只有我们这样的厂商才会关心，所以 Apple 连个官方文档头没提供，目前所能掌握的东西只有头文件里的注释。</p>
<p>推荐读者在看这篇文章之前先阅读一篇外国友人写的<a href="http://ramezanpour.net/post/2014/08/03/configure-and-manage-vpn-connections-programmatically-in-ios-8/">教程</a>，他把步骤描述的很详细，基本上照着一步步做就行了。我当时也是看了这篇文章，然后根据自己摸索才弄明白的。这里只点出需要注意的重点。</p>
<h2 id="_2">准备工作</h2>
<p>你需要 Enable App ID 中的 “VPN Configuration &amp; Control”，然后在应用的 “Capabilities” 中打开 “Personal VPN”，这时候 Xcode 会完成一些初始化工作。</p>
<p>最后，链接上 “NetworkExtension.framework”，然后在代码里<code>#import &lt;NetworkExtension/NetworkExtension.h&gt;</code>就 OK 了。</p>
<h2 id="_3">工作流程</h2>
<p>NE 的工作流程基本上分为以下几步：</p>
<p><strong>加载系统配置</strong></p>
<p>这步很重要，初次操作 NE 时一定别忘了先加载，否则将会出现一些莫名其妙的问题。</p>
<div class="codehilite"><pre><span class="c1">// init VPN manager</span>
<span class="nb">self</span><span class="p">.</span><span class="n">vpnManager</span> <span class="o">=</span> <span class="p">[</span><span class="bp">NEVPNManager</span> <span class="n">sharedManager</span><span class="p">];</span>

<span class="c1">// load config from perference</span>
<span class="p">[</span><span class="n">_vpnManager</span> <span class="nl">loadFromPreferencesWithCompletionHandler</span><span class="p">:</span><span class="o">^</span><span class="p">(</span><span class="bp">NSError</span> <span class="o">*</span><span class="n">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Do something</span>
<span class="p">}];</span>
</pre></div>


<p>这里需要说明一下，NE 需要给系统安装一个配置文件（类似于 mobileconfig）才能工作，应用在退出后可以在系统设置的 VPN 选项中手动开启 VPN。这个配置文件和 NEVPNManager 是不会自动同步的，也就是说每次操作 NEVPNManager，都必须先从配置文件加载内容，如果做了修改，一定要记得保存。</p>
<p>而且，如果手动在系统设置里面把配置文件删除，NEVPNManager 的内容还是会存在的。所以，每次启动 VPN 之前都应该加载一下配置，确保配置文件存在。</p>
<p><strong>添加或修改 IPSec 或 IKEv2 配置信息（以 IPSec 为例）</strong></p>
<div class="codehilite"><pre><span class="c1">// config IPSec protocol</span>
<span class="bp">NEVPNProtocolIPSec</span> <span class="o">*</span><span class="n">p</span> <span class="o">=</span> <span class="p">[[</span><span class="bp">NEVPNProtocolIPSec</span> <span class="n">alloc</span><span class="p">]</span> <span class="n">init</span><span class="p">];</span>
<span class="n">p</span><span class="p">.</span><span class="n">username</span> <span class="o">=</span> <span class="s">@&quot;[Your username]&quot;</span><span class="p">;</span>
<span class="n">p</span><span class="p">.</span><span class="n">serverAddress</span> <span class="o">=</span> <span class="s">@&quot;[Your server address]&quot;</span><span class="p">;;</span>

<span class="c1">// get password persistent reference from keychain</span>
<span class="n">p</span><span class="p">.</span><span class="n">passwordReference</span> <span class="o">=</span> <span class="p">[</span><span class="nb">self</span> <span class="nl">searchKeychainCopyMatching</span><span class="p">:</span><span class="s">@&quot;VPN_PASSWORD&quot;</span><span class="p">];</span>

<span class="c1">// PSK</span>
<span class="n">p</span><span class="p">.</span><span class="n">authenticationMethod</span> <span class="o">=</span> <span class="n">NEVPNIKEAuthenticationMethodSharedSecret</span><span class="p">;</span>
<span class="n">p</span><span class="p">.</span><span class="n">sharedSecretReference</span> <span class="o">=</span> <span class="p">[</span><span class="nb">self</span> <span class="nl">searchKeychainCopyMatching</span><span class="p">:</span><span class="s">@&quot;PSK&quot;</span><span class="p">];</span>

<span class="cm">/*</span>
<span class="cm">// certificate</span>
<span class="cm">p.identityData = [NSData dataWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@&quot;client&quot; ofType:@&quot;p12&quot;]];</span>
<span class="cm">p.identityDataPassword = @&quot;[Your certificate import password]&quot;;</span>
<span class="cm">*/</span>

<span class="n">p</span><span class="p">.</span><span class="n">localIdentifier</span> <span class="o">=</span> <span class="s">@&quot;[VPN local identifier]&quot;</span><span class="p">;</span>
<span class="n">p</span><span class="p">.</span><span class="n">remoteIdentifier</span> <span class="o">=</span> <span class="s">@&quot;[VPN remote identifier]&quot;</span><span class="p">;</span>

<span class="n">p</span><span class="p">.</span><span class="n">useExtendedAuthentication</span> <span class="o">=</span> <span class="nb">YES</span><span class="p">;</span>
<span class="n">p</span><span class="p">.</span><span class="n">disconnectOnSleep</span> <span class="o">=</span> <span class="nb">NO</span><span class="p">;</span>
</pre></div>


<p>上面的代码应该放到<code>loadFromPreferencesWithCompletionHandler</code>的 block 中执行，这样可以确保系统配置已经加载完成。</p>
<p>IPSec 协议里的密码以及预共享密钥都需要是一个 KeyChain 中密码的永久引用（persistent reference）。</p>
<p>如果用证书来作为 IKE 的认证方式，而且 Server 端用的是自签发证书，则需要手工将 CA 导入到 iOS 设备。目前 Apple 还没提供添加授信证书的方法。</p>
<p><strong>保存配置</strong></p>
<div class="codehilite"><pre><span class="p">[</span><span class="n">_vpnManager</span> <span class="nl">saveToPreferencesWithCompletionHandler</span><span class="p">:</span><span class="o">^</span><span class="p">(</span><span class="bp">NSError</span> <span class="o">*</span><span class="n">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">NSLog</span><span class="p">(</span><span class="s">@&quot;Save config failed [%@]&quot;</span><span class="p">,</span> <span class="n">error</span><span class="p">.</span><span class="n">localizedDescription</span><span class="p">);</span>
<span class="p">}];</span>
</pre></div>


<p><strong>启动 VPN</strong></p>
<div class="codehilite"><pre><span class="bp">NSError</span> <span class="o">*</span><span class="n">startError</span><span class="p">;</span>
<span class="p">[</span><span class="n">_vpnManager</span><span class="p">.</span><span class="n">connection</span> <span class="nl">startVPNTunnelAndReturnError</span><span class="p">:</span><span class="o">&amp;</span><span class="n">startError</span><span class="p">];</span>
<span class="k">if</span> <span class="p">(</span><span class="n">startError</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">NSLog</span><span class="p">(</span><span class="s">&quot;Start VPN failed: [%@]&quot;</span><span class="p">,</span> <span class="n">startError</span><span class="p">.</span><span class="n">localizedDescription</span><span class="p">);</span>
<span class="p">}</span>
</pre></div>


<p>根据目前的测试结果来看，<code>startVPNTunnelAndReturnError</code>只会在配置有误的时候才会返回 Error。其他时候，比如协议协商失败、连接超时等系统都会直接弹出对话框。</p>
<h2 id="_4">坑</h2>
<p>由于没有官方文档说明，不知道是调用方式不对还是 NE 本身不稳定，开发过程中遇到了很多大坑：</p>
<ol>
<li>上面提到的，系统配置文件和 NEVPNManager 内容不同步，需要监听 “NEVPNConfigurationChangeNotification” 消息。</li>
<li>NEVPNManager 的操作基本上都是异步的，改配置时必须确保 load 完成，启动 VPN 时必须确保 save 完成。</li>
<li>
<p>有时候创建、保存配置一切正常，但是启动时就会提示 “未知错误”。这时候需要在系统设置里面手动启动一次 VPN，然后程序就可以正常启动了……有时候手动启动也不成，那就得把配置文件删除，然后重新安装……</p>
<blockquote>
<p>2015-3-13 更新解决方法：</p>
<p>在调用 NEVPNManager 的 <code>saveToPreferencesWithCompletionHandler</code> 方法前，应将它的 <code>enabled</code> 属性置成 “YES”。</p>
</blockquote>
</li>
<li>
<p>配置 IPSec 协议时，密码相关的（证书密码除外）必须得是 KeyChain 的永久引用，即<code>kSecReturnPersistentRef</code>需要是 YES。</p>
</li>
<li>获取 VPN 状态时，NEVPNConnection 的 status 属性是不支持 KVO 的，需要监听 “NEVPNStatusDidChangeNotification” 事件。这点应该是 By-design 的，但是这个问题当时困扰我很久……</li>
</ol>
<h2 id="_5">完整代码</h2>
<p>上文提到的国际友人曾经遇到了一些问题（可以查看他文章下面的评论），这种问题基本上是因为坑#2 导致的。为了向他解释我的代码没问题，我根据他的代码写了一个简单的 Demo。没写全，但是基本可用。我这一切正常，他说他复制过去还有问题……</p>
<blockquote>
<p><strong>2014-12-19 更新</strong></p>
<p>如果<code>localIdentifier</code>和<code>remoteIdentifier</code>设置的不对也可能导致这个问题。我测试的 IPSec 服务端把这两个字段去掉了，所以一直没注意~</p>
</blockquote>
<p><em>以下代码来自 Gist，需自备梯子～</em></p>
<script src="https://gist.github.com/zqqf16/cbcbd2254e6cb965f1a3.js"></script>
        </section>
        <div class="post-nav">
    		<!-- Duoshuo Comment BEGIN -->
<div class="ds-thread"></div>
<script type="text/javascript">
	var duoshuoQuery = {short_name:"zqqf16"};
	(function() {
		var ds = document.createElement('script');
		ds.type = 'text/javascript';ds.async = true;
		ds.src = 'http://static.duoshuo.com/embed.js';
		ds.charset = 'UTF-8';
		(document.getElementsByTagName('head')[0]
		|| document.getElementsByTagName('body')[0]).appendChild(ds);
	})();
</script>
<!-- Duoshuo Comment END -->

    	</div>
    </div>

</article>

        </section>
    </div>
    <footer class="main-footer">
        <div class="vertical-container">
            <div class="wrapper">
                <p class="copy">&copy; zqqf16</p>
            </div>
        </div>
    </footer>
    <script>
      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
      })(window,document,'script','//www.google-analytics.com/analytics.js','ga');
      ga('create', 'UA-41282906-2', 'auto');
      ga('require', 'displayfeatures');
      ga('send', 'pageview');
    </script>
</body>
</html>
